5章 Trubo Streamによるリアルタイム更新 | Turbo Rails チュートリアル
この章では、Turbo StreamのテンプレートをAction Cableでブロードキャストして、Webページ上でリアルタイムに更新する方法を学びます。
Turbo Stream と Action Cable によるリアルタイム更新
Turbo Stream形式は、Action Cableと組み合わせることで、わずか数行のコードでウェブページをリアルタイムに更新することができます。実際の用途としては、例えば、グループチャット、通知、電子メールサービスなどがあります。
電子メールサービスを例にとって考えてみましょう。新しいメールを受信したとき、手動でページを更新して新着情報を確認したくありませんよね。その代わりに、何もしなくても、新鮮なコンテンツでリアルタイムにメールフィードが更新されるようにしたいのです。サーバーサイドで発生した変更を、私たちの側で手動で操作することなく、ブラウザにプッシュさせたいのです。
Railsのバージョン5でAction Cableがリリースされ、Railsでこのリアルタイム動作を実装することが容易になりました。本章で説明するTurbo Railsの一部は、Action Cableの上に構築されています。Railsでのリアルタイム動作の実装がさらに簡単になり、しかも必要なコードも非常に少なくなりました。
この章で作成するもの
複数のユーザーが見積もりエディタを使用することを想像してみましょう。彼らは同僚が取り組んでいることのリアルタイムの更新を見たいと思います。
Quote#indexページで次のことを実現したい:
同僚が見積を作成するたびに、リアルタイムで見積リストに追加されるようにしたい
同僚が見積を更新するたびに、その更新がリアルタイムで見積の一覧に反映されるのを見たい
同僚が見積を削除するたびに、リアルタイムで見積の一覧から削除されるようにしたい
この例が少し煩雑に感じられても、Turbo Streamsを使ってQuotes#indexページをリアルタイムに更新する方法を学ぶことができます。次の章で学ぶことは、通知、電子メール、あるいはあらゆるActiveRecordモデルにも有効です。
Turbo Streamsで作成した見積書をブロードキャストする
Quotes#indexページをリアルタイムのページに変えてみましょう。同僚が見積を作成するたびに、Quotes#indexページに手動でページを更新することなく、作成された見積がリアルタイムで表示されるようにしたいと思います。
そのためには、作成された見積の HTML を、作成直後に見積エディタのユーザにブロードキャストするように、Quote モデルに指示しなければなりません。では、Quote モデルを更新しましょう。
code:ruby
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self }, target: "quotes" }
end
このコードの行を一緒に分解してみましょう。今は意味がわからなくても、後でブラウザで実験してみればはっきりするでしょう。
まず、after_create_commitコールバックを使って、新しい見積もりがデータベースに挿入されるたびにラムダ内の式が実行されるようにRuby on Railsアプリケーションに指示しています。
ラムダ式の2番目の部分は、より複雑です。これは、作成された見積のHTMLを "quotes" ストリームを購読しているユーザーにブロードキャストし、"quotes"というIDを持つDOMノードの前に追加するようRuby on Railsアプリケーションに指示しています。
これは具体的にはどういうことでしょうか?
"quotes"ストリームを購読してブラウザでHTMLを受信する方法は後で説明しますが、今はどんなHTMLが生成されているかに注目しましょう。
指示通り、broadcast_prepend_toメソッドは、prependアクションとtarget: "quotes"オプションで指定されたターゲット "quotes"で、quotes/_quote.html.erb 部分文をTurbo Stream形式でレンダリングしています。
code:html
<turbo-stream action="prepend" target="quotes">
<template>
<turbo-frame id="quote_123">
<!-- The HTML for the quote partial -->
</turbo-frame>
</template>
</turbo-stream>
前の章でTurbo Stream形式について学んだことを思い出せば、これはQuotesController#createアクションで生成された、作成した見積を見積の一覧の先頭に追加するためのHTMLと同じであることに気づくはずです!TurboはこのようなHTMLを受け取ると、賢く解釈して<template>の内容をidが"quotes"のDOMノードの先頭に追加することができます。
唯一の違いは、今回はAJAXリクエストに応答するのではなく、WebSocket経由でHTMLが配信されることです。
----------
注: この例では、作成された見積もりは IDが"quotes" の DOM ノードの前に追加されることを望んでいます。broadcast_prepend_toの代わりにbroadcast_append_toを使えば、新しい引用を "quotes" ターゲットの末尾に追加することもできます。
----------
ユーザーが "quotes" ストリームを購読するためには、Quotes#index ビューでそれを指定する必要があります。ビューのトップに一行のコードを追加してみましょう。
code:erb
<%# app/views/quotes/index.html.erb %>
<%= turbo_stream_from "quotes" %>
<%# 以前書いたすべてのHTMLマークアップ %>
turbo_stream_fromヘルパーが生成するHTMLは以下のようなものです。
code:html
<turbo-cable-stream-source
channel="Turbo::StreamsChannel"
signed-stream-name="very-long-string"
</turbo-cable-stream-source>
turbo_stream_fromヘルパーは、Turbo JavaScriptライブラリで使用されるカスタム要素を生成し、channel属性で指定されたチャンネル、より具体的にはsigned-stream-name属性で指定されたストリームをユーザーにサブスクライブするようにします。
channel属性内のTurbo::StreamsChannelは、Action Cableチャネルの名前です。Turbo Railsは常にこのチャンネルを使用するため、この属性は常に同じになります。
signed-stream-name属性は、引数として渡した"quotes"文字列の署名付きバージョンです。悪意のあるユーザーがこれを改ざんして、アクセスすべきでないストリームからHTMLを受信するのを防ぐために署名されています。これについては、次のセキュリティの章でより詳しく説明します。今のところ、この文字列をデコードして元の値 "quotes" を読み取れることだけを知っていればよいのです。
Quotes#indexページにいるすべてのユーザーは、現在Turbo::StreamsChannelを購読しており、"quotes"ストリームへのブロードキャストを待機しています。新しい見積がデータベースに挿入されるたびに、これらのユーザーはTurbo Stream形式のHTMLを受信し、Turboは作成された見積のマークアップを見積の一覧の先頭に追加します。
では、すべてが期待通りに動作することをテストしてみましょう。コードが期待通りに動作することを手動でテストする方法は2つあり、その両方を調べます。
コンソールでのTurbo Streamsのテスト
この章では、Quoteモデルに変更を加え、コンソールでテストを行いたい場合は、毎回、テストを行う前にrailsコンソールを再起動する必要があります。そうしないと予期せぬ結果を見ることになるかもしれません。
----------
注: コンソールでテストを開始する前に、アプリケーションで Redis が適切に設定されていることを確認する必要があります。
開発環境では、config/cable.yml は次のようなものであるべきです。
code:yaml
# config/cable.yml
development:
adapter: redis
url: redis://localhost:6379/1
# All the rest of the file
もしそうなら、この注の残りは読み飛ばしていただいて結構です。
そうでない場合は、まずAction Cableが使用するRedisをコンピュータにインストールし、bin/rails turbo:installコマンドを実行します。これにより、開発中のconfig/cable.ymlファイルが上記のような構成に更新されるはずです。そうなれば、チュートリアルを読み進めることができます!
訳注: developmentモードのときはasyncアダプタを使うのがデフォルトですが、これは同一のRailsプロセスの中でしか動かないみたいです。なので、rails console からAction Cableを試すときはRedisが要るようです(面倒な場合は無理に試さずにconsoleでのテストをスキップしてもいいかも)
----------
テストを実行するために、ブラウザでQuotes#indexページを開いてみましょう。すべてが正しく「配線」されていることをテストする最初の方法は、railsコンソールを開いて新しい見積を作成することです。
code:ruby
Quote.create!(name: "ブロードキャストされた見積")
これを実行すると、コンソールログに何が表示されるでしょうか。まず、見積の作成そのものです。
code:txt
TRANSACTION (0.1ms) begin transaction
Quote Create (0.4ms) INSERT INTO "quotes" ("name", "created_at", "updated_at") VALUES (?, ?, ?) "name", "Broadcasted quote"], "created_at", "2021-10-16 12:03:54.401034", ["updated_at", "2021-10-16 12:03:54.401034" TRANSACTION (0.8ms) commit transaction
見ての通り、データベースに見積が作成され、トランザクションがコミットされました。しかし、何か新しいことが起こっています。コンソールに次のような行が表示されるはずです。
code:txt
Rendered quotes/_quote.html.erb (Duration: 0.5ms | Allocations: 285)
ActionCable Broadcasting to quotes: "<turbo-stream action=\"prepend\" target=\"quotes\"><template><turbo-frame id=\"quote_908005754\">\nThe HTML of our quotes/_quote.html.erb partial</turbo-frame></template></turbo-stream>" これは非常に多くのテキストですが、非常に興味深い部分があります。
まず、このHTMLはActionCable経由で"quotes"という名前のストリームにブロードキャストされていることに気づきます。Quotes#indexビューに追加したturbo_stream_from "quotes"行のおかげで、私たちはこのストリームを購読しており、したがってブロードキャストされたHTMLを受信することができるのです。
次に、ブロードキャストされたHTMLがTurbo Stream形式であることに気づきます。これは、<template>の内容をターゲットの"quotes"に "prepend" するようにTurboに指示しています。確かにこれは、私たちがQuoteモデルに指示したことです。
3つ目、そして最後に注目すべきは、<template>に含まれるHTMLが、先ほど作成した見積のquotes/_quote.html.erbパーシャルによって生成されているという点です。Turboがフロントエンドでこのテンプレートを受け取ると、"quotes"というidを持つDOMノードに追加されます。
この動作をスケッチしてみましょう。Quotes#indexページはこのようになります:
https://scrapbox.io/files/6302cdd15ab7b3001fd86f2e.png
ここで、同僚が新しい見積を作成することを想像してみましょう。
after_create_commitコールバックのおかげで、作成された見積がデータベースに追加されると、broadcasts_prepend_toメソッドが呼び出されます。
Quotes#indexページでturbo_stream_fromメソッドを使ってそれらのブロードキャストを購読しています:
https://scrapbox.io/files/6302cee363146f001d5b6d6c.png
これら2行のコードは、以下のスケッチで説明されています:
https://scrapbox.io/files/6302cf21ab623500203ef847.png
Action Cable 上に構築された Turbo Rails のおかげで、これらの変更は、正しいストリーム名で正しいチャンネルを購読しているすべてのユーザーにリアルタイムで反映されます。変更を確認するためにページをリフレッシュする必要はありませんでした! たった 2 行のコードで、私たちのアプリケーションをリアルタイムのものにしたのです!
2つのブラウザウィンドウでTurbo Streamsをテストする
すべてが期待通りに動作していることをテストするもう一つの方法は、Quotes#indexページで2つのブラウザウィンドウを開いて並べて置くことです。2つのウィンドウのうち1つで、見積書を作成します。ページを更新しなくても、もう一方のウィンドウにすぐに変更が反映されることが確認できるはずです。
このように、私たちのアプリケーションは期待通りのリアクティブさを発揮しています!
Turbo Streamsの規約と糖衣構文
この章の最初の部分で書いたコードの量を、Quoteモデルで減らすことができます。
code:ruby
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self }, target: "quotes" }
end
上記のように、target: "quotes" オプションにより、ターゲット名を "quotes" に指定しています。デフォルトでは、targetオプションはmodel_name.pluralと等しく、このQuoteモデルのコンテキストでは "quotes" と等しくなります。この規約のおかげで、target: "quotes" オプションを削除することができます:
code:ruby
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes", partial: "quotes/quote", locals: { quote: self } }
end
コードを短くするために使える規約が他に2つあります。Turboでは、partialとlocalsの両オプションにデフォルト値が設定されています。
partialのデフォルト値は、モデルのインスタンスでto_partial_pathを呼び出すことと等しく、RailsのQuoteモデルのデフォルトでは "quotes/quote" と等しくなります。
localsのデフォルト値は{ model_name.element.to_sym => self }で、Quoteモデルの文脈では{ quote: self }と等しくなります。
これらはまさに、オプションとして渡した値です。したがって、以下のコードは以前と同じです。
code:ruby
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes" }
end
Ruby on Railsの規約を利用して、2行の(非常に短い)コードでアプリケーションをリアルタイムにすることができました!
これでTurbo Streamsの仕組みが理解できたので、Quoteモデルに対するリアルタイムCRUDを完成させるのは簡単でしょう。
Turbo Streamsで見積の更新をブロードキャストする
リアルタイムでの見積もり作成機能が動作するようになったので、同じ機能を見積の更新についても追加してみましょう。
見積の更新もブロードキャストするようにモデルに指示しましょう。
code:ruby
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes" }
after_update_commit -> { broadcast_replace_to "quotes" }
end
以上です。コンソールや2つのブラウザウィンドウでテストすると、すでに動作します! どのように動作するかを理解するために、railsのコンソールでテストしてみましょう。
code:ruby
Quote.first.update!(name: "Update from console")
先ほどと同じように、見積を作成すると、コンソールログに見積がデータベースで更新され、トランザクションがコミットされたことが表示されます。
code:txt
Quote Load (0.3ms) SELECT "quotes".* FROM "quotes" ORDER BY "quotes"."id" ASC LIMIT ? "LIMIT", 1
TRANSACTION (0.0ms) begin transaction
TRANSACTION (1.6ms) commit transaction
トランザクションがコミットされると、Quoteモデルのafter_update_commitコールバックが起動され、broadcast_replace_toメソッドが呼び出されます:
code:txt
Rendered quotes/_quote.html.erb (Duration: 0.6ms | Allocations: 285)
ActionCable Broadcasting to quotes: "<turbo-stream action=\"replace\" target=\"quote_908005754\"><template><turbo-frame id=\"quote_908005754\">\nHTML from the quotes/quote partial</turbo-frame></template></turbo-stream>" 前回と同様に、quotes/quoteパーシャルのHTMLが "quotes" ストリームにブロードキャストされていることが分かります。主な違いは、今回のアクションが "prepend" ではなく"replace" であることと、ターゲットのDOMノードが "quote_908005754" というIDを持つ見積カードであることです(「908005754」が更新された引用のIDです):
https://scrapbox.io/files/6302d3f7b1b4b6001de22758.png
受信したHTMLはターボに横取りされ、見積カードが入れ替わります:
https://scrapbox.io/files/6302d48192e771001d2be4fb.png
最後に実装したい機能は、ユーザが見積を削除したときに、アプリケーションをリアルタイムにすることです。これは次のセクションで行います。
Turbo Streamsで見積もり削除をブロードキャストする
データベースから見積が削除されたときに変更をブロードキャストするように、Quoteモデルに指示してみましょう。先ほどと同様に、これはモデル上のコールバックを使用して行われます。
code:rb
# app/models/quote.rb
class Quote < ApplicationRecord
# All the previous code
after_create_commit -> { broadcast_prepend_to "quotes" }
after_update_commit -> { broadcast_replace_to "quotes" }
after_destroy_commit -> { broadcast_remove_to "quotes" }
end
早速、この機能をrailsコンソールでテストして、期待通りに動作することを確認しましょう。ローカルデータベースに破壊できる見積書があることを確認し、以下のコマンドを実行してみましょう。
code:ruby
Quote.last.destroy!
ブラウザで確認できるように、期待通りに動作しています! ログを解析して、その理由を理解しましょう。
code:txt
Quote Load (0.3ms) SELECT "quotes".* FROM "quotes" ORDER BY "quotes"."id" DESC LIMIT ? "LIMIT", 1
TRANSACTION (0.1ms) begin transaction
Quote Destroy (0.4ms) DELETE FROM "quotes" WHERE "quotes"."id" = ? "id", 908005754
TRANSACTION (1.4ms) commit transaction
このログの最初の部分では、最後の見積がデータベースから取得され、その後トランザクションで破棄されていることがわかります。トランザクションが終了すると、Quoteモデルからafter_destroy_commitコールバックが起動され、broadcast_remove_toメソッドが呼び出されます。
code:txt
ActionCable Broadcasting to quotes: "<turbo-stream action=\"remove\" target=\"quote_908005754\"></turbo-stream>" いくつかのHTMLが "quotes" チャンネルからユーザーにブロードキャストされています。今回、このHTMLはTurboにidが"quote_908005754"のDOMノードを削除するように指示するだけです。"908005754"は先ほど削除された見積のデータベースidです。
https://scrapbox.io/files/63037e37356d7c001ebec17c.png
その結果、すべてのユーザーが見ている、Quotes#indexページから見積が消えます:
https://scrapbox.io/files/63037ec415e5c0001f4f6d07.png
見積もりモデルでのリアルタイムCRUDを完成させました。これはエキサイティングです!
この章を終えて次の章に行く前に、パフォーマンスについて話しておく必要があります。
ActiveJobでブロードキャストを非同期にする
私たちのQuoteモデルは現在以下のような感じです。
code:ruby
# app/models/quote.rb
class Quote < ApplicationRecord
# これまで書いたすべてのコード
after_create_commit -> { broadcast_prepend_to "quotes" }
after_update_commit -> { broadcast_replace_to "quotes" }
after_destroy_commit -> { broadcast_remove_to "quotes" }
end
ブロードキャスト部分をバックグラウンドジョブで非同期化することで、このコードのパフォーマンスを向上させることができる。そのためには、コールバックの内容を更新して、非同期の同等のものを使用すればよい。
code:ruby
# app/models/quote.rb
class Quote < ApplicationRecord
# これまで書いたすべてのコード
after_create_commit -> { broadcast_prepend_later_to "quotes" }
after_update_commit -> { broadcast_replace_later_to "quotes" }
after_destroy_commit -> { broadcast_remove_to "quotes" }
end
----------
注: broadcast_remove_later_toメソッドが存在しないのは、見積がデータベースから削除されると、バックグラウンドジョブが後でデータベース内のこの見積を取得してジョブを実行することが不可能になるためです。
----------
違いを見るために、railsコンソールを開いて、新しい見積もりを作成してみましょう。
code:ruby
Quote.create!(name: "匿名の見積")
ログをよく見てみると、結果は見積もりを作成したときと同じですが、ブロードキャスト部分が非同期で起こっていることがわかります。このように、Turbo::Streams::ActionBroadcastJobは、後でブロードキャストを実行するために必要なすべてのデータでキューイングされています。
code:txt
Enqueued Turbo::Streams::ActionBroadcastJob (Job ID: 1eecd0c8-53fd-43ed-af8a-073b7d85c2fe) to Async(default) with arguments: "quotes", {:action=>:prepend, :target=>"quotes", :targets=>nil, :locals=>{:quote=>#<GlobalID:0x00007f9a39e861a8 @uri=#<URI::GID gid://hotwire-course/Quote/908005756>>}, :partial=>"quotes/quote"}
そして、先ほどと同じようにquotes/_quote.html.erbパーシャルのHTMLをレンダリングするジョブが実行されます。
code:txt
Performing Turbo::Streams::ActionBroadcastJob (Job ID: 1eecd0c8-53fd-43ed-af8a-073b7d85c2fe) from Async(default) enqueued at 2021-10-16T17:24:32Z with arguments: "quotes", {:action=>:prepend, :target=>"quotes", :targets=>nil, :locals=>{:quote=>#<GlobalID:0x00007f9a3e03a630 @uri=#<URI::GID gid://hotwire-course/Quote/908005756>>}, :partial=>"quotes/quote"}
Turbo Streamを非同期でブロードキャストすることは、パフォーマンス上の理由から好ましい方法です。
さらなる糖衣構文
複数のリアルタイムモデルを持っていると、それらのモデルで書くコールバックが非常に似ていることに気がつくと思います。Ruby on Railsは素晴らしいフレームワークなので、これらの3つのコールバックを常に書かないようにするための構文上の工夫があります。Quoteモデルでそれらを同等の短いバージョンに置き換えてみましょう。
code:ruby
# app/models/quote.rb
class Quote < ApplicationRecord
# これまで書いたすべてのコード
# after_create_commit -> { broadcast_prepend_later_to "quotes" }
# after_update_commit -> { broadcast_replace_later_to "quotes" }
# after_destroy_commit -> { broadcast_remove_to "quotes" }
# Those three callbacks are equivalent to the following single line
broadcasts_to ->(quote) { "quotes" }, inserts_by: :prepend
end
これら3つのコールバックは、1行のコードと等価です。なぜラムダが必要なのかについては、Turbo Streamsとセキュリティに関する次の章で説明します。とりあえず、これは引用の作成、更新、削除を "quotes" ストリームに非同期でブロードキャストしたいことを意味していると理解しましょう。
私たちの最終的なQuoteモデルの実装は以下のようになります:
code:ruby
# app/models/quote.rb
class Quote < ApplicationRecord
# これまで書いたすべてのコード
broadcasts_to ->(quote) { "quotes" }, inserts_by: :prepend
end
まとめ
Turbo Railsのおかげで、このアプリケーションをリアルタイムに変換するのに必要なコードはわずか2行でした。
Quoteモデルでは、作成、更新、削除を "quotes" ストリームにブロードキャストする3つのコールバックを設定しています。broadcasts_to メソッドのおかげで、これら3つのコールバックは1行で定義できます。
Quotes#index ビューでは、"quotes" ストリームにブロードキャストされた変更を購読することを明示的に記述しています。
あとはTurboがすべて処理してくれるので、Ruby on Railsアプリケーションを快適に操作することができます。
次の章では、Turbo Streamsとセキュリティについて説明します。プライベートなデータを間違ったユーザーにブロードキャストしないようにするために、Turbo Streamsでどのようにセキュリティが機能するかを説明します。
次: